Skip to content

Stream gRPC client calls in both directions - #1642

Merged
jlouvel merged 6 commits into
3.0from
feat/grpc-client-streaming
Nov 5, 2026
Merged

jlouvel merged 6 commits into
3.0from
feat/grpc-client-streaming

Conversation

@jlouvel

@jlouvel jlouvel commented Nov 5, 2026

Copy link
Copy Markdown
Collaborator

Second step of R6a (#1561), after #1641 for the server: GrpcClientAdapter makes calls of every streaming type. Restlet can now be both ends of a streaming gRPC call.

Change

RestletClientStream sent the whole request and read the whole response, at most one message each way. The transport factory therefore refused every non-unary method with UNIMPLEMENTED. Now:

  • The exchange starts with the request headers. The request body is fed frame by frame from a bounded queue and ends at the half-close.
    • The body is written by the Netty client's existing background writer: on HTTP/2, a body of unknown size is written concurrently, and handle() returns at the response head.
    • So messages flow both ways over one stream, and no core change is needed.
  • Outbound readiness. Each frame written is reported to the SDK (onSentBytes), so isReady() and onReady follow the network, with the ready threshold at half the queue budget. A sender that ignores readiness and queues more than the budget fails with RESOURCE_EXHAUSTED, like the server's response queue (Host client-streaming and bidirectional gRPC methods #1641). A single message larger than the budget is still accepted.
  • Inbound demand.
    • The response is read in chunks as it arrives and handed to the SDK.
    • Bytes not yet consumed by its deframer, which only reads the messages the application requested, stay within the queue budget.
    • So a slow or paused reader stops the reads, and HTTP/2 flow control stops the server.
    • Trailers are read at the end of the body.
    • The SDK deframer bounds message size (RESOURCE_EXHAUSTED).
  • Configuration. New GrpcClientAdapter.setMaxQueuedBytes: per call and per direction, default 256 KiB, at least 16 KiB, mirroring GrpcServerAdapter.
  • Factory. Refuses only methods of type UNKNOWN.
  • Unchanged. Plaintext only, no retry, keepalive or connection controls; per-call authority overrides and compression stay refused.

changes.md, readme.md and the adapter Javadoc are updated.

Tests

The new GrpcClientStreamingTestCase runs every case twice: against grpc-java's own Netty server, with a fixed 64 KiB flow-control window so the peer can't absorb what the client holds, and against GrpcServerAdapter, so that both ends are Restlet.

Test Checks
serverStreamingPreservesOrder 50 × 4 KiB responses, in order
clientStreamingSendsEveryMessage 50 × 4 KiB requests, one summary reply
bidirectionalCallsExchangeMessagesBothWays Each reply received before the next request, 5 rounds on one stream
requestsFollowTheServerDemand With a 32 KiB budget and the server requesting nothing, a client respecting readiness has sent under half of 4 MiB after 1 s. Once requested, all 1024 messages arrive
responsesFollowTheClientDemand With the client requesting nothing, a server respecting readiness has produced under half of 4 MiB after 1 s. Once requested, all 1024 arrive
messagesLargerThanTheQueueBudgetStreamThrough A 512 KiB message each way through a 16 KiB budget
cancellingAStreamReachesTheServerAndFreesTheSlot Client cancel mid-stream reaches the server's onCancel and frees the only slot
anOpenStreamEndsAtItsDeadline A never-half-closed bidirectional call ends with DEADLINE_EXCEEDED

The two "every message" tests send 50 messages, about 200 KiB: their stubs ignore readiness, so they must stay within the queue budget.

The demand tests stall on purpose before moving 4 MiB, so the Restlet peer gets a 30 s call limit instead of its 5 s default. The same change is applied to #1641's unrequestedMessagesPushBackOnTheClient, which hit the default on a loaded machine.

mvn -o verify -pl org.restlet.ext.grpc passes: 93 tests.

rejectsStreamingAndOutboundLimitBeforeSending became rejectsUnknownMethodTypesAndOutboundLimitBeforeSending. The other client tests are unchanged and pass, so unary behaviour is preserved, including cancellation before and after headers, deadlines, admission and the trailers-only path.

Not in this PR

  • Retiring the R0 manual-framing streaming fixtures in core (GrpcSdkTransportSpikeTestCase). They're test-only diagnostics, so they can go in a follow-up once R6a is complete.
  • Idle-disconnect behaviour of long-lived streams, and connection controls (GOAWAY/draining, PING/keepalive), which stay rejected.

🤖 Generated with Claude Code

RestletClientStream sent the whole request and read the whole response,
so the transport factory refused every non-unary method. Start the
HTTP/2 exchange with the request headers instead, and feed the request
body frame by frame from a bounded queue, ending at the half-close; the
Netty client already writes a body of unknown size in the background on
HTTP/2 and returns at the response head, so messages flow both ways.

Report written frames to the SDK, so readiness follows the network, and
fail a sender that queues past the budget with RESOURCE_EXHAUSTED. Read
the response as it arrives, keeping the bytes the deframer hasn't
consumed within the budget, so an application that requests nothing
pushes back on the server. Add GrpcClientAdapter.setMaxQueuedBytes,
mirroring the server adapter. Only methods of unknown type are refused.

Test every streaming type against grpc-java's Netty server and against
GrpcServerAdapter, with readiness, demand, large messages, cancellation
and deadlines. Give the server's push-back test room on loaded machines.

Part of #1561 (R6a).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Jerome Louvel <[email protected]>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Terminal responses can leave flow-control-blocked request writers and exchanges uncleared, while readiness accounting can reject valid large-message streams with RESOURCE_EXHAUSTED.

2 open issues
What changed in this PR

Extends the SDK-native gRPC client transport to support all streaming call types over Restlet HTTP/2, complementing the existing server-side streaming support.

Changes:

  • Streams requests and responses concurrently, with queue budgets tied to outbound readiness and inbound demand.
  • Adds configurable client queue limits and permits all known gRPC method types.
  • Adds interoperability tests against Netty and Restlet peers, and updates documentation.
File Description
readme.md Advertises client support for every streaming type.
org.restlet.ext.grpc/​src/​test/​java/​org/​restlet/​ext/​grpc/​internal/​RestletClientStreamTestCase.java Supplies queue budgets to updated constructors.
org.restlet.ext.grpc/​src/​test/​java/​org/​restlet/​ext/​grpc/​GrpcServerAdapterTestCase.java Extends the timeout for the stalled-demand test.
org.restlet.ext.grpc/​src/​test/​java/​org/​restlet/​ext/​grpc/​GrpcClientStreamingTestCase.java Tests streaming, demand, large messages, cancellation, and deadlines against both peers.
org.restlet.ext.grpc/​src/​test/​java/​org/​restlet/​ext/​grpc/​GrpcClientAdapterTestCase.java Updates rejection coverage to unknown method types.
org.restlet.ext.grpc/​src/​main/​java/​org/​restlet/​ext/​grpc/​internal/​RestletTransportFactory.java Passes queue budgets and accepts streaming methods.
org.restlet.ext.grpc/​src/​main/​java/​org/​restlet/​ext/​grpc/​internal/​RestletClientStream.java Implements concurrent streaming with bounded queues and SDK flow-control accounting.
org.restlet.ext.grpc/​src/​main/​java/​org/​restlet/​ext/​grpc/​GrpcClientAdapter.java Exposes queue-budget configuration and documents streaming support.
org.restlet.ext.grpc/​pom.xml Adds JUnit parameterized-test support.
changes.md Records client streaming capabilities and remaining limitations.

Review effort: Balanced

jlouvel and others added 2 commits November 5, 2026 09:42
Move the request queueing out of the stream sink into begin, send and
enqueue, and the exchange's request building and response handling
into newRequest and receive, to bring their complexity within
SonarCloud's limit; behaviour is unchanged. Use unnamed catch variables.

Responses now stream as they arrive, so the one message a server sends
before waiting may reach the application before a cancellation, as
with grpc-netty: cancellationAfterHeadersReleasesAdmissionAndClosesExactlyOnce
now allows at most one message instead of none.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Jerome Louvel <[email protected]>
A server may answer and reset the stream before the client half-closes,
while the request is still written. The disconnect then failed calls
whose whole response had arrived, and a writer held by flow control
kept the exchange after the call released its slot. A disconnect now
only wakes the reader and the writer: a complete response is still
read, and a broken one fails the head or the body read. When the call
ends with its request unfinished, the queued frames are dropped and the
HTTP request is aborted, resetting the stream and freeing the writer.

The queue rejected the next large message of a sender respecting
readiness while the tail of the previous one was still queued. A
message may now start while the queue is within its budget, and grow up
to the message-size limit once started, so the queue stays within its
budget plus one message.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Jerome Louvel <[email protected]>
@jlouvel jlouvel self-assigned this Nov 5, 2026
@jlouvel
jlouvel requested a balanced review from Copilot November 5, 2026 17:39

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Peer-disconnect draining can incur substantial CPU and allocation overhead from one-byte reads, while client queue-overflow rejection and admission-slot cleanup lack regression coverage.

2 open issues
2 resolved since last review

Review effort: Balanced

…eer is gone

A sender ignoring readiness towards a peer that requests nothing is now
tested against both peers: the call ends once with RESOURCE_EXHAUSTED
and frees its only slot.

Once the peer is gone, the response the connector buffered was read one
byte at a time while the inbound budget was full; it is now drained in
whole chunks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Active issues can reject valid large unknown-length requests with RESOURCE_EXHAUSTED and leave unfinished HTTP/2 exchanges retained until timeout after response completion.

No open issues 🎉

2 previously missed issues

In code that hasn't changed since last review

Medium severity Unknown-length messages can exceed the budget before draining

org.restlet.ext.grpc/​src/​main/​java/​org/​restlet/​ext/​grpc/​internal/​RestletClientStream.java:273

For unknown-length marshaller streams, grpc-java emits the header and intermediate buffers with messages == 0, then counts the message on its final buffer, so messages > 0 is not a message-start marker. A single valid 100 KiB request through a 16 KiB budget can therefore reset messageStart to its own queued bytes and fail with RESOURCE_EXHAUSTED before the writer starts draining. Track admission at actual message boundaries independently of frame counts, and add a large unknown-length-marshaller regression.

Medium severity Terminal completion can leave an unfinished exchange until timeout

org.restlet.ext.grpc/​src/​main/​java/​org/​restlet/​ext/​grpc/​internal/​RestletClientStream.java:326

requestEnded becomes true when the entity writer returns, but NettyClientCall.writeRequest() still waits for writability before sending LastHttpContent. If the last DATA write leaves the channel non-writable and the server completes its response, this guard skips aborting the unfinished exchange, retaining its writer and HTTP/2 stream until the connector timeout. Attempt abort on terminal completion even after the entity writer returns; the connector already ignores cancellation of completed exchanges.

2 resolved since last review

Review effort: Balanced

…d exchange

grpc-java frames a message of unknown length before counting it, so
frame message counts do not mark where messages start: a single large
message could be refused with RESOURCE_EXHAUSTED before the writer
drained it. The queue now reads the 5-byte gRPC prefixes to find each
message start and its declared length.

The entity writer may return before the connector sends the last
content, so the exchange is now aborted whenever the call ends; the
connector ignores the abort of a completed exchange.

The queue wait moves out of the entity class, and the stalled-sender
tests wait for their counters to settle instead of sleeping.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@jlouvel

jlouvel commented Nov 5, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the two "previously missed issues" from the latest Copilot review in 494bc9f:

  • Unknown-length messages exceeding the budget before draining. Frame message counts no longer mark message starts. The queue reads the 5-byte gRPC prefixes to find each message start, which must be within the budget, and its declared length, which must be within the message-size limit. New test largeMessagesOfUnknownLengthAreAccepted, against both peers, sends two 100 KiB messages through a 16 KiB queue with a marshaller of unknown length. Before the fix it failed against both with RESOURCE_EXHAUSTED: Request queue byte budget exceeded.
  • Terminal completion leaving an unfinished exchange. The exchange is now aborted whenever the call ends, even once the entity writer returned. NettyClientCall.cancel() ignores a released exchange, or one whose request was sent and response completed. There is no regression test for this one, as the non-writable channel before the last content is not reproducible on demand.

Also from SonarCloud: the queue wait moved out of the entity class (complexity), endRequest moved into State, and the stalled-sender tests wait for their counters to settle instead of sleeping. The remaining S110 (6 parents) comes from extending OutputRepresentation, as the server adapter already does.

@sonarqubecloud

sonarqubecloud Bot commented Nov 5, 2026

Copy link
Copy Markdown

@jlouvel
jlouvel merged commit 308957c into 3.0 Nov 5, 2026
3 checks passed
@jlouvel
jlouvel deleted the feat/grpc-client-streaming branch November 5, 2026 20:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants